'use client'
import { useState, useEffect, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useAuth } from '@/components/AuthProvider'
import { MessageUI } from '@/lib/types/database'
import { DEFAULT_MODEL } from '@/lib/config/models'
import ChatMessages from '@/components/chat/ChatMessages'
import ModelSelector from '@/components/ModelSelector'
import { IoSendOutline, IoStopOutline, IoCopyOutline } from 'react-icons/io5'
import { useChatContext } from '@/lib/contexts/ChatContext'
import { useUsageLimit } from '@/lib/hooks/useUsageLimit'
import { deleteMessage } from '@/lib/utils/chatMessageUtils'
export default function ChatPage() {
const { chatId } = useParams()
const router = useRouter()
const { currentChatId, setCurrentChatId, updateChatTimestamp } = useChatContext()
const { user } = useAuth()
const [messages, setMessages] = useState<MessageUI[]>([])
const [inputValue, setInputValue] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL.name)
const [isLoading, setIsLoading] = useState(false)
const [currentChat, setCurrentChat] = useState<any>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const loadMessagesAbortControllerRef = useRef<AbortController | null>(null)
const { usageInfo } = useUsageLimit(user?.id || null)
// Sync URL chatId with context
useEffect(() => {
if (chatId && typeof chatId === 'string') {
setCurrentChatId(chatId)
}
}, [chatId, setCurrentChatId])
// Load messages for current chat
useEffect(() => {
// Cancel any ongoing message loading
if (loadMessagesAbortControllerRef.current) {
loadMessagesAbortControllerRef.current.abort()
}
if (currentChatId && user?.id) {
// Immediately clear messages to prevent showing old chat messages
setMessages([])
setCurrentChat(null)
setIsLoading(true)
loadChatMessages(currentChatId)
setInputValue('')
} else {
setMessages([])
setCurrentChat(null)
setInputValue('')
setIsLoading(false)
}
}, [currentChatId, user?.id])
// Cleanup on unmount
useEffect(() => {
return () => {
if (loadMessagesAbortControllerRef.current) {
loadMessagesAbortControllerRef.current.abort()
}
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
}, [])
const loadChatMessages = async (chatId: string) => {
try {
setIsLoading(true)
// Create new abort controller for this request
loadMessagesAbortControllerRef.current = new AbortController()
const signal = loadMessagesAbortControllerRef.current.signal
const { supabase } = await import('@/lib/supabase')
// Load both chat details and messages
const [chatResult, messagesResult] = await Promise.all([
supabase
.from('chats')
.select('*')
.eq('id', chatId)
.abortSignal(signal)
.single(),
supabase
.from('messages')
.select('*')
.eq('chat_id', chatId)
.order('sequence_number', { ascending: true })
.abortSignal(signal)
])
// Check if this request was cancelled or if we switched to a different chat
if (signal.aborted || currentChatId !== chatId) {
return
}
if (chatResult.error) {
console.error('Error loading chat:', chatResult.error)
// Only redirect if the chat doesn't exist (not found)
if (chatResult.error.code === 'PGRST116') {
setTimeout(() => router.push('/dashboard/chat'), 1000)
}
return
}
if (messagesResult.error) {
console.error('Error loading messages:', messagesResult.error)
return
}
// Double-check we're still on the same chat before setting state
if (currentChatId !== chatId) {
return
}
// Set chat details
setCurrentChat(chatResult.data)
// Set model from chat if available
if (chatResult.data.model) {
setSelectedModel(chatResult.data.model)
}
const formattedMessages: MessageUI[] = messagesResult.data.map((msg: any) => ({
id: msg.id,
type: msg.type,
content: msg.content,
timestamp: new Date(msg.created_at),
model: msg.model,
tool_results: msg.tool_results,
context_info: msg.context_info
}))
// Final check before setting messages
if (currentChatId === chatId && !signal.aborted) {
setMessages(formattedMessages)
}
} catch (error: any) {
// Don't log errors for aborted requests
if (error.name === 'AbortError') {
return
}
console.error('Error loading chat messages:', error)
// Only redirect on genuine errors, not during normal loading
if (currentChatId === chatId) {
setTimeout(() => router.push('/dashboard/chat'), 1000)
}
} finally {
// Only update loading state if we're still on the same chat
if (currentChatId === chatId) {
setIsLoading(false)
}
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!inputValue.trim() || !user?.id || isStreaming) return
const userMessage = inputValue.trim()
setInputValue('')
setIsStreaming(true)
// Add user message to UI immediately
const tempUserMessage: MessageUI = {
id: `temp-${Date.now()}`,
type: 'user',
content: userMessage,
timestamp: new Date()
}
const currentMessages = currentChatId ? [...messages, tempUserMessage] : [tempUserMessage]
setMessages(currentMessages)
try {
abortControllerRef.current = new AbortController()
const response = await fetch('/api/standalone-chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: currentMessages,
model: selectedModel,
userId: user?.id,
chatId: currentChatId
}),
signal: abortControllerRef.current.signal
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to send message')
}
// Handle streaming response
const reader = response.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let aiResponse = ''
const tempAiMessage: MessageUI & { _isStreaming?: boolean } = {
id: `temp-ai-${Date.now()}`,
type: 'ai',
content: '',
timestamp: new Date(),
model: selectedModel,
_isStreaming: true
}
setMessages(prev => [...prev, tempAiMessage])
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.type === 'content') {
aiResponse += data.content
setMessages(prev => prev.map(msg =>
msg.id === tempAiMessage.id
? { ...msg, content: aiResponse }
: msg
))
} else if (data.type === 'done') {
// Update the timestamp for existing chat without full refresh
if (currentChatId && updateChatTimestamp) {
updateChatTimestamp(currentChatId)
}
} else if (data.type === 'error') {
throw new Error(data.error)
}
} catch (e) {
// Skip invalid JSON
}
}
}
}
// Remove streaming flag
setMessages(prev => prev.map(msg =>
msg.id === tempAiMessage.id
? { ...msg, _isStreaming: false }
: msg
))
} catch (error: any) {
console.error('Error sending message:', error)
if (error.name === 'AbortError') {
// Request was cancelled
setMessages(prev => prev.slice(0, -2)) // Remove both user and AI messages
} else {
// Show error message
const errorMessage: MessageUI = {
id: `error-${Date.now()}`,
type: 'ai',
content: `Sorry, I encountered an error: ${error.message}`,
timestamp: new Date()
}
setMessages(prev => [...prev.slice(0, -1), errorMessage]) // Replace AI message with error
}
} finally {
setIsStreaming(false)
abortControllerRef.current = null
}
}
const stopExecution = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e as any)
}
}
const getAvatarUrl = () => user?.user_metadata?.avatar_url || null
const getInitials = () => {
const fullName = user?.user_metadata?.full_name
if (fullName) {
return fullName.split(' ').map((n: string) => n[0]).join('').toUpperCase()
}
return user?.email?.[0]?.toUpperCase() || 'U'
}
const getPlaceholder = () => {
if (isStreaming) return "AI is thinking..."
return "Message the AI..."
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
}
const handleDeleteMessage = async (messageId: string) => {
try {
await deleteMessage(messageId)
// Remove the message from the local state
setMessages(prev => prev.filter(msg => msg.id.toString() !== messageId))
// Update chat metadata if needed
if (currentChatId && updateChatTimestamp) {
updateChatTimestamp(currentChatId)
}
} catch (error: any) {
console.error('Error deleting message:', error)
// You could show a toast notification here
throw error // Re-throw to let the Message component handle the error state
}
}
if (isLoading) {
return (
<div className="flex flex-col h-full">
<div className="border-b border-white/10 bg-black/40 backdrop-blur-sm px-4 py-2 md:pl-4 pl-4">
<h1 className="text-sm font-medium text-slate-300">Loading...</h1>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-white/10 bg-black/40 backdrop-blur-sm px-4 py-2 md:pl-4 pl-4">
<div className="flex items-center justify-between">
<h1 className="text-sm font-medium text-slate-300 truncate flex-1">
{currentChat ? currentChat.title : 'Chat'}
</h1>
{currentChatId && (
<div className="flex items-center gap-2 text-xs text-slate-400">
<span className="font-mono">ID: {currentChatId.slice(0, 8)}...</span>
<button
onClick={() => copyToClipboard(currentChatId)}
className="p-1 hover:bg-white/10 rounded text-slate-400 hover:text-slate-300 transition-colors"
title="Copy chat ID"
>
<IoCopyOutline className="w-3 h-3" />
</button>
</div>
)}
</div>
</div>
{/* Messages Area */}
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-4">
<ChatMessages
allDisplayMessages={messages}
isStreaming={isStreaming}
currentChat={currentChat}
contextInfo={null}
toolResults={[]}
getAvatarUrl={getAvatarUrl}
getInitials={getInitials}
onFileClick={async () => {}} // No file operations in standalone chat
onDeleteMessage={handleDeleteMessage}
/>
</div>
</div>
</div>
{/* Input Area */}
<div className="bg-black/40 backdrop-blur-sm px-4 py-3">
<div className="max-w-4xl mx-auto">
<form onSubmit={handleSubmit}>
<div className="relative bg-white/5 backdrop-blur-sm border border-white/10 rounded-lg transition-all">
<textarea
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
e.target.style.height = 'auto'
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`
}}
onKeyDown={handleKeyDown}
placeholder={getPlaceholder()}
className="w-full bg-transparent text-slate-200 placeholder-slate-400 border-0 outline-none resize-none px-4 py-3 pr-12 pb-12 min-h-[3rem] max-h-[7.5rem]"
disabled={isStreaming}
/>
<div className="absolute left-4 bottom-3 flex items-center gap-2">
<ModelSelector
selectedModel={selectedModel}
onModelChange={setSelectedModel}
disabled={isStreaming}
variant="minimal"
className="min-w-0"
usageInfo={usageInfo}
/>
</div>
<div className="absolute right-2 bottom-3 flex items-center gap-2">
{isStreaming ? (
<button
type="button"
onClick={stopExecution}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-white/10 rounded-md transition-colors"
title="Stop generation"
>
<IoStopOutline className="h-4 w-4" />
</button>
) : (
<button
type="submit"
disabled={!inputValue.trim() || isStreaming}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-white/10 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Send message"
>
<IoSendOutline className="h-4 w-4" />
</button>
)}
</div>
</div>
</form>
</div>
</div>
</div>
)
}